;;; jupyter-repl-client.el --- A Jupyter REPL client -*- lexical-binding: t -*-

;; Copyright (C) 2018-2024 Nathaniel Nicandro

;; Author: Nathaniel Nicandro <nathanielnicandro@gmail.com>
;; Created: 08 Jan 2018

;; This program is free software; you can redistribute it and/or
;; modify it under the terms of the GNU General Public License as
;; published by the Free Software Foundation; either version 3, or (at
;; your option) any later version.

;; This program is distributed in the hope that it will be useful, but
;; WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
;; General Public License for more details.

;; You should have received a copy of the GNU General Public License
;; along with GNU Emacs; see the file COPYING.  If not, write to the
;; Free Software Foundation, Inc., 59 Temple Place - Suite 330,
;; Boston, MA 02111-1307, USA.

;;; Commentary:

;; A Jupyter REPL for Emacs.
;;
;; The main entry points are `jupyter-run-repl' and `jupyter-connect-repl'.
;;
;; When called interactively, `jupyter-run-repl' asks for a kernel to
;; start, connects a `jupyter-repl-client' to the selected kernel, and
;; pops up a REPL buffer.  The main difference of
;; `jupyter-connect-repl' is that it will obtain the kernel's
;; connection info by asking for the JSON file containing it to start
;; connection to a kernel.
;;
;; Additionally, `jupyter-repl-associate-buffer' associates the
;; `current-buffer' with a REPL client appropriate for the buffer's
;; `major-mode'.  Associating a buffer with a REPL client enables the minor
;; mode `jupyter-repl-interaction-mode'.
;;
;; `jupyter-repl-interaction-mode' adds the following keybindings for
;; interacting with a REPL client:
;;
;;     C-c C-c `jupyter-eval-line-or-region'
;;     C-c C-l `jupyter-eval-file'
;;     M-i `jupyter-inspect-at-point'
;;     C-c C-r `jupyter-repl-restart-kernel'
;;     C-c C-i `jupyter-repl-interrupt-kernel'
;;     C-c C-z `jupyter-repl-pop-to-buffer'

;;; Code:

(defgroup jupyter-repl nil
  "A Jupyter REPL client"
  :group 'jupyter)

(eval-when-compile (require 'subr-x))
(eval-and-compile
  (require 'jupyter-client)
  (require 'jupyter-base))
(require 'jupyter-mime)
(require 'jupyter-kernelspec)
(require 'jupyter-widget-client)
(require 'ring)
(require 'face-remap)

(declare-function jupyter-notebook-process "jupyter-server")
(declare-function jupyter-launch-notebook "jupyter-server")
(declare-function jupyter-server "jupyter-server")

;;; User variables

(defface jupyter-repl-input-prompt
  '((((class color) (min-colors 88) (background light))
     :foreground "darkseagreen2")
    (((class color) (min-colors 88) (background dark))
     :foreground "darkolivegreen"))
  "Face used for the input prompt."
  :group 'jupyter-repl)

(defface jupyter-repl-output-prompt
  '((((class color) (min-colors 88) (background light))
     :foreground "indianred3")
    (((class color) (min-colors 88) (background dark))
     :foreground "darkred"))
  "Face used for the output prompt."
  :group 'jupyter-repl)

(defface jupyter-repl-traceback
  '((((class color) (min-colors 88) (background light))
     :background "LightYellow2")
    (((class color) (min-colors 88) (background dark))
     :background "firebrick"))
  "Face used for a traceback."
  :group 'jupyter-repl)

(defcustom jupyter-repl-maximum-size 1024
  "Maximum number of lines before the buffer is truncated."
  :type 'integer
  :group 'jupyter-repl)

(defcustom jupyter-repl-maximum-is-complete-timeout 2
  "Maximum number of seconds to wait for an is-complete reply.
When no is-complete reply is received from the kernel within this
timeout, the built-in is-complete handler is used."
  :type 'integer
  :group 'jupyter-repl)

(defcustom jupyter-repl-history-maximum-length 100
  "The maximum number of history elements to keep track of."
  :type 'integer
  :group 'jupyter-repl)

(defcustom jupyter-repl-prompt-margin-width 12
  "The width of the margin which displays prompt strings."
  :type 'integer
  :group 'jupyter-repl)

(defcustom jupyter-repl-cell-pre-send-hook nil
  "Hook run before sending the contents of an input cell to a kernel.
The hook is run with `point' at the cell code beginning position
and before the contents of the cell are extracted from the buffer
for sending to the kernel."
  :type 'hook
  :group 'jupyter-repl)

(defcustom jupyter-repl-cell-post-send-hook nil
  "Hook run after sending the contents of an input cell to a kernel.
The hook is run with `point' at the cell code beginning
position."
  :type 'hook
  :group 'jupyter-repl)

(defcustom jupyter-repl-allow-RET-when-busy nil
  "Allow RET to insert a newline when the kernel is busy.
Normally when the kernel is busy, pressing RET at an input cell
is disallowed.  This is because, when the kernel is busy, it does
not respond to an is_complete_request message and that message is
used to avoid sending incomplete code to the kernel.

If this variable is non-nil, RET is allowed to insert a newline.
In this case, pressing RET on an empty line, i.e.  RET RET, will
send the code to the kernel."
  :type 'boolean
  :group 'jupyter-repl)

(defcustom jupyter-repl-without-is-complete nil
  "Whether or not is_complete_request handling is done.
If this variable is nil, an is_complete_request is usually sent
to the kernel on every press of
\\<jupyter-repl-mode-map>\\[jupyter-repl-ret] to determine if the
code is syntactically correct before sending it to be executed.
If the kernel responds to this message by saying that the code is
correct, then the code is sent to be executed immediately.

If this variable is non-nil,
\\<jupyter-repl-mode-map>\\[jupyter-repl-ret] no longer sends
is_complete_request messages nor does what is explained by
`jupyter-repl-use-builtin-is-complete' and just inserts a newline
and indents.  In order to send an execute_request in this case,
you have to press \\<jupyter-repl-mode-map>\\[jupyter-repl-send]."
  :type 'boolean
  :group 'jupyter-repl)

(defcustom jupyter-repl-echo-eval-p nil
  "Copy evaluation input to a REPL cell if non-nil.
If non-nil, and when calling the `jupyter-eval-*' functions like
`jupyter-eval-line-or-region', copy the input into a REPL cell.
Otherwise the evaluation request is sent to the kernel without
displaying the code of the request in the REPL.

Note, output generated by requests will always be sent to the
REPL buffer whenever this variable is non-nil.  When the REPL
buffer isn't visible, output will also be sent to pop-up buffers
as is done when this variable is nil."
  :type 'boolean
  :group 'jupyter-repl)

(defcustom jupyter-repl-completion-at-point-hook-depth nil
  "The DEPTH of `jupyter-completion-at-point' in `completion-at-point-functions'.
This will be set as the DEPTH argument to the `add-hook' call
when adding Jupyter based completion functionality to
`completion-at-point-functions'.  A value of nil means the
default depth of 0, i.e. added as the first entry of
`completion-at-point-functions'.  This might prevent other hooks
like `lsp-completion-at-point' from running.

If you'd prefer to give `jupyter-completion-at-point' lower
priority, set this variable to something like 1.  See `add-hook'."
  :type '(choice (integer :tag "Hook depth")
                 (const :tag "Default depth of 0" nil))
  :group 'jupyter-repl)

(defcustom jupyter-repl-interaction-mode-line-format " JuPy[%s]"
  "Format to use when producing the mode line.
The format specifier %s is replaced with the status of the
kernel."
  :type 'string
  :group 'jupyter-repl)

;;; Implementation

(defclass jupyter-repl-client (jupyter-widget-client jupyter-kernel-client)
  ((buffer
    :type (or null buffer)
    :initform nil
    :documentation "The REPL buffer whose
`jupyter-current-client' is this client.")
   (wait-to-clear
    :type boolean
    :initform nil
    :documentation "Whether or not we should wait to clear the
current output of the cell.  Set when the kernel sends a
`:clear-output' message.")))

(defvar-local jupyter-repl-lang-buffer nil
  "A buffer with the `major-mode' set to the REPL language's `major-mode'.")

(defvar-local jupyter-repl-lang-mode nil
  "The `major-mode' corresponding to the REPL's language.")

(defvar-local jupyter-repl-history nil
  "The history of the current Jupyter REPL.")

(defvar-local jupyter-repl-use-builtin-is-complete nil
  "Whether or not to send an is_complete_request to a kernel.
If a Jupyter kernel does not respond to an is_complete_request,
the buffer local value of this variable is set to t and code in a
cell is considered complete if the last line in a code cell is a
blank line, i.e. if \\<jupyter-repl-mode-map>\\[jupyter-repl-ret]
is pressed twice in a row.

Note `jupyter-repl-without-is-complete' takes precedence over
this variable so when `jupyter-repl-without-is-complete' is
non-nil you have to press
\\<jupyter-repl-mode-map>\\[jupyter-repl-send] to send the code
regardless of the setting of this variable..")

(cl-generic-define-context-rewriter jupyter-repl-mode (mode &rest modes)
  `(jupyter-repl-lang-mode (derived-mode ,mode ,@modes)))

;;; Macros

(defmacro jupyter-with-repl-buffer (client &rest body)
  "Switch to CLIENT's REPL buffer and evaluate BODY.
`inhibit-read-only' is let bound to t while evaluating
BODY.  After evaluation, if the current buffer is visible in some
window, set the window point to the value of `point' in the
buffer."
  (declare (indent 1) (debug (symbolp &rest form)))
  `(with-current-buffer (oref ,client buffer)
     (let ((inhibit-read-only t))
       (prog1 (progn ,@body)
         (let ((win (get-buffer-window)))
           (when win (set-window-point win (point))))))))

(defvar jupyter-repl-inhibit-continuation-prompts nil
  "Non-nil when continuation prompts are suppressed.
See `jupyter-repl-insert-continuation-prompts'.")

(defmacro jupyter-repl-without-continuation-prompts (&rest body)
  "Evaluate BODY without inserting continuation prompts."
  (declare (debug (&rest form)))
  `(let ((jupyter-repl-inhibit-continuation-prompts t))
     ,@body))

(defmacro jupyter-repl-append-output (client req &rest body)
  "Switch to CLIENT's buffer, move to the end of REQ, and evaluate BODY.
REQ is a `jupyter-request' previously made using CLIENT, a REPL
client.

`point' is moved to the `jupyter-repl-cell-beginning-position' of
the cell *after* REQ, this position is where any newly generated
output of REQ should be inserted.

Also handles any terminal control codes in the appended output."
  (declare (indent 2) (debug (symbolp &rest form)))
  `(jupyter-with-repl-buffer ,client
     (let ((buffer-undo-list t))
       (save-excursion
         (jupyter-repl-goto-cell ,req)
         (jupyter-repl-next-cell)
         (jupyter-with-insertion-bounds
             beg end (jupyter-with-control-code-handling ,@body)
           (put-text-property beg end 'read-only t)
           (set-buffer-modified-p nil))))))

(defmacro jupyter-with-repl-lang-buffer (&rest body)
  "Evaluate BODY in the `jupyter-repl-lang-buffer' of the `current-buffer'.
The contents of `jupyter-repl-lang-buffer' is erased before
evaluating BODY."
  (declare (indent 0) (debug (&rest form)))
  (let ((client (make-symbol "clientvar")))
    `(let ((,client jupyter-current-client))
       (with-current-buffer jupyter-repl-lang-buffer
         (let ((inhibit-read-only t)
               (jupyter-current-client ,client))
           (erase-buffer)
           ,@body)))))

(defmacro jupyter-with-repl-cell (&rest body)
  "Narrow to the current cell, evaluate BODY, then widen.
The cell is narrowed to the region between and including
`jupyter-repl-cell-code-beginning-position' and
`jupyter-repl-cell-code-end-position'."
  (declare (indent 0) (debug (&rest form)))
  `(save-excursion
     (save-restriction
       (narrow-to-region (jupyter-repl-cell-code-beginning-position)
                         (jupyter-repl-cell-code-end-position))
       ,@body)))

(defmacro jupyter-repl-with-single-undo (&rest body)
  "Evaluate BODY, remove all undo boundaries created during its evaluation."
  (declare (indent 0) (debug (&rest form)))
  (let ((handle (make-symbol "handle")))
    `(let ((,handle (prepare-change-group)))
       (unwind-protect
           (progn
             (activate-change-group ,handle)
             ,@body)
         (undo-amalgamate-change-group ,handle)
         (accept-change-group ,handle)))))

;;; Text insertion

(defun jupyter-repl-newline ()
  "Insert a read-only newline into the `current-buffer'."
  (insert (propertize "\n" 'read-only t)))

(cl-defmethod jupyter-insert :around (mime-or-plist
                                      &context (major-mode jupyter-repl-mode) &rest _ignore)
  "If MIME was inserted, mark the region that was inserted as read only.
Do this only when the `major-mode' is `jupyter-repl-mode'."
  (if (listp mime-or-plist) (cl-call-next-method)
    (jupyter-with-insertion-bounds
        beg end (cl-call-next-method)
      (add-text-properties beg end '(read-only t)))))

(cl-defmethod jupyter-insert ((_mime (eql :application/vnd.jupyter.widget-view+json)) data
                              &context ((and (require 'websocket nil t)
                                             (require 'simple-httpd nil t)
                                             (and jupyter-current-client
                                                  (object-of-class-p
                                                   jupyter-current-client
                                                   'jupyter-widget-client))
                                             t)
                                        (eql t))
                              &optional _metadata)
  (jupyter-widgets-display-model jupyter-current-client (plist-get data :model_id)))

;;; Util

(defun jupyter-repl-completing-read-repl-buffer (&optional mode)
  "Return a REPL buffer, selecting from all available ones.
Return nil if no REPL buffers are available.

MODE has the same meaning as in
`jupyter-repl-available-repl-buffers'."
  (when-let* ((buffers (jupyter-repl-available-repl-buffers mode))
              (names (mapcar #'buffer-name buffers))
              (buffer (completing-read "REPL buffer: " names nil t)))
    (when (equal buffer "")
      (error "No REPL buffer selected"))
    (get-buffer buffer)))

;;; Prompt

(defconst jupyter-repl-input-prompt-format "In [%d] ")
(defconst jupyter-repl-output-prompt-format "Out [%d] ")
(defconst jupyter-repl-busy-prompt "In [*] ")

(defsubst jupyter-repl--prompt-string (ov)
  (nth 0 (overlay-get ov 'jupyter-prompt)))

(defsubst jupyter-repl--prompt-face (ov)
  (nth 1 (overlay-get ov 'jupyter-prompt)))

(defun jupyter-repl--prompt-scale-factor ()
  (expt text-scale-mode-step
        text-scale-mode-amount))

(defun jupyter-repl--prompt-display-value (str face)
  "Return the margin display value for a prompt STR.
FACE is the `font-lock-face' to use for STR."
  (list '(margin left-margin)
        (let ((s (concat
                  (make-string
                   (let ((width (floor
                                 (- left-margin-width
                                    (* (length str)
                                       (jupyter-repl--prompt-scale-factor))
                                    (expt 2 (jupyter-repl--prompt-scale-factor))))))
                     (if (>= width 0) width 0))
                   ?\s)
                  str)))
          (propertize s 'fontified t 'font-lock-face face))))

(defun jupyter-repl--reset-prompt-display (ov)
  (when-let* ((prompt (jupyter-repl--prompt-string ov))
              (face (or (jupyter-repl--prompt-face ov)
                        'jupyter-repl-input-prompt))
              (md (jupyter-repl--prompt-display-value prompt face)))
    (overlay-put ov 'after-string (propertize " " 'display md))))

(defun jupyter-repl--reset-prompts (&optional size-hint)
  "Re-calculate all prompt strings in the buffer.
Also set the local value of `left-margin-width' to
`jupyter-repl-prompt-margin-width'."
  (let ((ovs (cl-remove-if-not
              (lambda (ov) (overlay-get ov 'jupyter-prompt))
              (overlays-in (point-min) (point-max))))
        (max-width size-hint))
    (dolist (ov ovs)
      (cl-callf max max-width (length (jupyter-repl--prompt-string ov))))
    (cl-callf * max-width (jupyter-repl--prompt-scale-factor))
    (setq-local left-margin-width (ceiling max-width)
                jupyter-repl-prompt-margin-width left-margin-width)
    (dolist (ov ovs)
      (jupyter-repl--reset-prompt-display ov))))

(defun jupyter-repl--make-prompt (str face props)
  "Make a prompt overlay for the character before POS.
STR is used as the prompt string and FACE is its
`font-lock-face'.  Add PROPS as text properties to the character."
  (let ((size-hint (* (length str)
                      (jupyter-repl--prompt-scale-factor))))
    (when (> size-hint left-margin-width)
      (jupyter-repl--reset-prompts size-hint)))
  (let ((ov (make-overlay (1- (point)) (point) nil t)))
    (overlay-put ov 'jupyter-prompt (list str face))
    (overlay-put ov 'evaporate t)
    (jupyter-repl--reset-prompt-display ov)
    (add-text-properties (overlay-start ov) (overlay-end ov) props)
    (overlay-recenter (point))))

(defun jupyter-repl-insert-prompt (&optional type count)
  "Insert a REPL prompt according to TYPE.
TYPE can either be `in', `out', or `continuation'.  A nil TYPE is
interpreted as `in'.

When TYPE is `in' and COUNT is a number, insert a prompt with a
count equal to COUNT.  For TYPE `in' and COUNT not a number, the
execution-count of the `jupyter-current-client' will be used as
count.  COUNT is ignored otherwise."
  (setq type (or type 'in))
  (unless (memq type '(in out continuation))
    (error "Prompt type can only be (`in', `out', or `continuation')"))
  (jupyter-repl-without-continuation-prompts
   (let ((inhibit-read-only t))
     ;; The newline that `jupyter-repl--make-prompt' will overlay.
     (insert (propertize "\n" 'read-only (not (eq type 'continuation))))
     (cond
      ((eq type 'in)
       (let ((count (if (numberp count) count
                      (oref jupyter-current-client execution-count))))
         (jupyter-repl--make-prompt
          (format jupyter-repl-input-prompt-format count)
          'jupyter-repl-input-prompt
          `(jupyter-cell (beginning ,count))))
       ;; Prevent prompt overlay from inheriting text properties of code at the
       ;; beginning of a cell.
       ;;
       ;; The rear-nonsticky property prevents code inserted after
       ;; this character from inheriting any of this character's text
       ;; properties.
       ;;
       ;; The front-sticky property prevents `point' from being
       ;; trapped between the newline of the prompt overlay and this
       ;; invisible character.
       (insert (propertize " "
                           'read-only t 'invisible t
                           'rear-nonsticky t 'front-sticky t))
       ;; The insertion of a new prompt starts a new cell, don't consider the
       ;; buffer modified anymore.  This is also an indicator for when undo's
       ;; can be made in the buffer.
       (set-buffer-modified-p nil)
       (setq buffer-undo-list '((t . 0))))
      ((eq type 'out)
       ;; Output is normally inserted by first going to the end of the output
       ;; for the request.  The end of the ouput for a request is at the
       ;; beginning of the next cell after the request which is why we get the
       ;; cell count of the previous cell
       (let ((count (jupyter-repl-previous-cell-count)))
         (jupyter-repl--make-prompt
          (format jupyter-repl-output-prompt-format count)
          'jupyter-repl-output-prompt
          `(jupyter-cell (out ,count))))
       ;; See the note above about the invisible character for input prompts
       (insert (propertize " " 'read-only t 'invisible t 'front-sticky t)))
      ((eq type 'continuation)
       (jupyter-repl--make-prompt
        ;; This needs to be two characters wide for some
        ;; reason, otherwise the continuation prompts will
        ;; be missing one character.
        "  " 'jupyter-repl-input-prompt
        `(read-only nil rear-nonsticky t)))))))

(defun jupyter-repl-prompt-string ()
  "Return the prompt string of the current input cell."
  (jupyter-repl--prompt-string
   (car (overlays-at (jupyter-repl-cell-beginning-position)))))

(defun jupyter-repl-cell-reset-prompt ()
  "Reset the current prompt back to its default."
  (jupyter-repl-cell-update-prompt
   (format jupyter-repl-input-prompt-format (jupyter-repl-cell-count))))

(defun jupyter-repl-cell-update-prompt (str &optional face)
  "Update the current cell's input prompt.
STR is the replacement prompt string.  If FACE is non-nil, it
should be a face that the prompt will use and defaults to
`jupyter-repl-input-prompt'."
  (when-let* ((ov (car (overlays-at (jupyter-repl-cell-beginning-position)))))
    (overlay-put ov 'jupyter-prompt (list str face))
    (jupyter-repl--reset-prompt-display ov)))

(defun jupyter-repl-cell-mark-busy ()
  "Mark the current cell as busy."
  (when (equal (jupyter-repl-prompt-string)
               (format jupyter-repl-input-prompt-format
                       (jupyter-repl-cell-count)))
    (jupyter-repl-cell-update-prompt jupyter-repl-busy-prompt)))

(defun jupyter-repl-cell-unmark-busy ()
  "Un-mark the current cell as busy."
  (when (equal (jupyter-repl-prompt-string) jupyter-repl-busy-prompt)
    (jupyter-repl-cell-update-prompt
     (format jupyter-repl-input-prompt-format
             (jupyter-repl-cell-count)))))

(defun jupyter-repl-update-cell-count (n)
  "Set the current cell count to N."
  (when (or (jupyter-repl-cell-beginning-p)
            (zerop (save-excursion (jupyter-repl-previous-cell))))
    (setf (nth 1 (get-text-property
                  (jupyter-repl-cell-beginning-position)
                  'jupyter-cell))
          n)
    (when (string-match-p "In \\[[0-9]+\\]" (jupyter-repl-prompt-string))
      (jupyter-repl-cell-reset-prompt))))

(defun jupyter-repl-cell-count ()
  "Return the cell count of the cell at `point'."
  (let ((pos (if (jupyter-repl-cell-beginning-p) (point)
               (save-excursion
                 (jupyter-repl-previous-cell)
                 (point)))))
    (nth 1 (get-text-property pos 'jupyter-cell))))

(defun jupyter-repl-previous-cell-count ()
  "Return the cell count of the previous cell before `point'."
  (save-excursion
    (jupyter-repl-previous-cell)
    (jupyter-repl-cell-count)))

(defun jupyter-repl-cell-request ()
  "Return the `jupyter-request' of the current cell."
  (get-text-property (jupyter-repl-cell-beginning-position) 'jupyter-request))

;;; Cell motions

(defun jupyter-repl-cell-beginning-position ()
  "Return the cell beginning position of the current cell.
If `point' is already at the beginning of the current cell,
return `point'.

If the end of a cell is found before the beginning of one, i.e.
when `point' is somewhere inside the output of a cell, raise an
error.

If the beginning of the buffer is found before the beginning of a
cell, raise a `beginning-of-buffer' error."
  (let ((pos (point)))
    (while (not (jupyter-repl-cell-beginning-p pos))
      (setq pos (previous-single-property-change pos 'jupyter-cell))
      (if pos (when (jupyter-repl-cell-end-p pos)
                (error "Found end of previous cell"))
        (if (jupyter-repl-cell-beginning-p (point-min))
            (setq pos (point-min))
          (signal 'beginning-of-buffer nil))))
    pos))

(defun jupyter-repl-cell-end-position ()
  "Return the cell ending position of the current cell.
This is similar to `jupyter-repl-cell-beginning-position' except
the position at the end of the current cell is returned and an
error is raised if the beginning of a cell is found before an
end.

Note: If the current cell is the last cell in the buffer,
`point-max' is considered the end of the cell."
  (let ((pos (point)))
    (catch 'unfinalized
      (while (not (jupyter-repl-cell-end-p pos))
        (setq pos (next-single-property-change pos 'jupyter-cell))
        (if pos (when (jupyter-repl-cell-beginning-p pos)
                  (error "Found beginning of next cell"))
          ;; Any unfinalized cell must be at the end of the buffer.
          (throw 'unfinalized (point-max))))
      pos)))

(defun jupyter-repl-cell-code-beginning-position ()
  "Return the beginning of the current cell's code.
The code beginning position is

   `jupyter-repl-cell-beginning-position' + 2

There is an extra invisible character after the prompt."
  (+ (jupyter-repl-cell-beginning-position) 2))

(defun jupyter-repl-cell-code-end-position ()
  "Return the end of the current cell's code.
In the case of the last cell in the REPL buffer, i.e. an
unfinalized cell, the code ending position is `point-max'."
  (jupyter-repl-cell-end-position))

(defun jupyter-repl-next-cell (&optional N)
  "Go to the beginning of the next cell.
Move N times where N defaults to 1.  Return the count of cells
left to move."
  (or N (setq N 1))
  (catch 'done
    (while (> N 0)
      (let ((pos (next-single-property-change (point) 'jupyter-cell)))
        (while (and pos (not (jupyter-repl-cell-beginning-p pos)))
          (setq pos (next-single-property-change pos 'jupyter-cell)))
        (unless (when pos (goto-char pos) (setq N (1- N)))
          (goto-char (point-max))
          (throw 'done t)))))
  N)

(defun jupyter-repl-previous-cell (&optional N)
  "Go to the beginning of the previous cell.
Move N times where N defaults to 1.  Return the count of cells
left to move.

Note, if `point' is not at the beginning of the current cell, the
first move is to the beginning of the current cell."
  (or N (setq N 1))
  (catch 'done
    (let ((starting-pos (point)))
      (while (> N 0)
        (let ((pos (previous-single-property-change (point) 'jupyter-cell)))
          (while (and pos (not (jupyter-repl-cell-beginning-p pos)))
            (setq pos (previous-single-property-change pos 'jupyter-cell)))
          (unless (when pos (goto-char pos) (setq N (1- N)))
            (goto-char (point-min))
            ;; Handle edge case when the first cell is at the beginning of the
            ;; buffer.  This happens, for example, when erasing the buffer.
            (when (and (/= (point) starting-pos)
                       (jupyter-repl-cell-beginning-p (point)))
              (setq N (1- N)))
            (throw 'done t))))))
  N)

(defun jupyter-repl-goto-cell (req)
  "Go to the cell beginning position of REQ.
REQ should be a `jupyter-request' associated with a cell in the
 `current-buffer'.  Note that the `current-buffer' is assumed to
 be a Jupyter REPL buffer."
  (goto-char (point-max))
  (unless (catch 'done
            (while (= (jupyter-repl-previous-cell) 0)
              (when (eq (jupyter-repl-cell-request) req)
                (throw 'done t))))
    (error "Cell for request not found")))

(defun jupyter-repl-forward-cell (&optional arg)
  "Go to the code beginning of the cell after the current one.
ARG is the number of cells to move and defaults to 1."
  (interactive "^p")
  (or arg (setq arg 1))
  (jupyter-repl-next-cell arg)
  (goto-char (jupyter-repl-cell-code-beginning-position)))

(defun jupyter-repl-backward-cell (&optional arg)
  "Go to the code beginning of the cell before the current one.
ARG is the number of cells to move and defaults to 1."
  (interactive "^p")
  (or arg (setq arg 1))
  ;; Ignore the case when `point' is in the output of a cell, in this case
  ;; `jupyter-repl-previous-cell' will go to the previous cell.
  (ignore-errors (goto-char (jupyter-repl-cell-beginning-position)))
  (jupyter-repl-previous-cell arg)
  (goto-char (jupyter-repl-cell-code-beginning-position)))

(defun jupyter-repl-map-cells (beg end input output)
  "Call INPUT or OUTPUT on the corresponding cells between BEG and END.
For every input or output cell between BEG and END, call INPUT or
OUTPUT, respectively, with the buffer narrowed to the cell.
INPUT and OUTPUT are functions of no arguments.

Note the narrowed regions may not be full input/output cells if
BEG and END are within an input/output cell."
  (declare (indent 2))
  (save-excursion
    (save-restriction
      (let (next)
        (while (/= beg end)
          (widen)
          (cond
           ((eq (get-text-property beg 'field) 'cell-code)
            (setq next (min end (field-end beg t)))
            (narrow-to-region beg next)
            (funcall input))
           (t
            (setq next (or (text-property-any
                            beg end 'field 'cell-code)
                           end))
            (narrow-to-region beg next)
            (funcall output)))
          (setq beg next))))))

;;; Predicates

(defun jupyter-repl-cell-beginning-p (&optional pos)
  "Is POS the beginning of a cell?
POS defaults to `point'."
  (setq pos (or pos (point)))
  (eq (nth 0 (get-text-property pos 'jupyter-cell)) 'beginning))

(defun jupyter-repl-cell-end-p (&optional pos)
  "Is POS the end of a cell?
POS defaults to `point'."
  (setq pos (or pos (point)))
  (or (= pos (point-max))
      (eq (nth 0 (get-text-property pos 'jupyter-cell)) 'end)))

(defun jupyter-repl-multiline-p (text)
  "Is TEXT a multi-line string?"
  (string-match-p "\n" text))

(defun jupyter-repl-cell-line-p ()
  "Is the current line a cell input line?"
  (let ((pos (point)))
    (ignore-errors
      (save-excursion
        (unless (= pos (jupyter-repl-cell-beginning-position))
          (jupyter-repl-previous-cell))
        (<= (jupyter-repl-cell-code-beginning-position)
            pos
            (jupyter-repl-cell-code-end-position))))))

(defun jupyter-repl-cell-finalized-p ()
  "Has the current cell been finalized?"
  (or (not (jupyter-repl-cell-line-p))
      (/= (jupyter-repl-cell-end-position) (point-max))))

(defun jupyter-repl-connected-p ()
  "Is the `jupyter-current-client' connected to its kernel?"
  (and jupyter-current-client
       (jupyter-connected-p jupyter-current-client)))

;;; Modifying cell code, truncating REPL buffer

(defun jupyter-repl-cell-output ()
  "Return the output of the current cell."
  (unless (jupyter-repl-cell-finalized-p)
    (error "Cell not finalized"))
  (let ((beg (jupyter-repl-cell-end-position))
        (end (save-excursion
               (jupyter-repl-next-cell)
               (jupyter-repl-cell-beginning-position))))
    (buffer-substring beg end)))

(defun jupyter-repl-cell-code ()
  "Return the code of the current cell."
  (buffer-substring
   (jupyter-repl-cell-code-beginning-position)
   (jupyter-repl-cell-code-end-position)))

(defun jupyter-repl-cell-code-position ()
  "Return the relative position of `point' with respect to the cell code."
  (unless (jupyter-repl-cell-line-p)
    (error "Not in code of cell"))
  (1+ (- (point) (jupyter-repl-cell-code-beginning-position))))

(defun jupyter-repl-finalize-cell (req)
  "Finalize the current input cell.
REQ is the `jupyter-request' to associate with the current cell.
Place `point' at `point-max'."
  (goto-char (point-max))
  (let ((beg (jupyter-repl-cell-beginning-position))
        (count (jupyter-repl-cell-count)))
    (jupyter-repl-newline)
    (put-text-property (1- (point)) (point) 'jupyter-cell `(end ,count))
    (put-text-property beg (1+ beg) 'jupyter-request req)
    ;; Remove this property so that text can't be inserted at the start of the
    ;; cell or after any continuation prompts.  See
    ;; `jupyter-repl-insert-prompt'.
    (remove-text-properties beg (point) '(rear-nonsticky))
    (add-text-properties beg (point) '(read-only t))
    ;; reset the undo list so that a completed cell doesn't get undone.
    (setq buffer-undo-list '((t . 0)))))

(defun jupyter-repl-replace-cell-code (new-code)
  "Replace the current cell code with NEW-CODE.
If NEW-CODE is a buffer use `replace-buffer-contents' to replace
the cell code.  Otherwise NEW-CODE should be a string, the current
cell code will be erased and NEW-CODE inserted in its place."
  (if (bufferp new-code)
      (jupyter-with-repl-cell
        (jupyter-repl-with-single-undo
          ;; Need to create a single undo step here because
          ;; `replace-buffer-contents' adds in unwanted undo boundaries.
          ;;
          ;; Tests failing on Appveyor due to `replace-buffer-contents' not
          ;; supplying the right arguments to `after-change-functions' so call
          ;; the change functions manually.  Seen on Emacs 26.1.
          ;;
          ;; For reference see https://debbugs.gnu.org/cgi/bugreport.cgi?bug=32278
          (let ((inhibit-modification-hooks t)
                (beg (point-min))
                (end (point-max))
                (new-len (with-current-buffer new-code
                           (- (point-max) (point-min)))))
            (run-hook-with-args
             'before-change-functions beg end)
            (replace-buffer-contents new-code)
            (run-hook-with-args
             'after-change-functions
             beg (+ beg new-len) (- end beg)))))
    (goto-char (jupyter-repl-cell-code-beginning-position))
    (delete-region (point) (jupyter-repl-cell-code-end-position))
    (insert-and-inherit new-code)))

(defun jupyter-repl-truncate-buffer ()
  "Truncate the `current-buffer' based on `jupyter-repl-maximum-size'.
The `current-buffer' is assumed to be a Jupyter REPL buffer.  If
the `current-buffer' is larger than `jupyter-repl-maximum-size'
lines, truncate it to something less than
`jupyter-repl-maximum-size' lines."
  (save-excursion
    (when (= (forward-line (- jupyter-repl-maximum-size)) 0)
      (jupyter-repl-next-cell)
      (delete-region (point-min) (point)))))

(defun jupyter-repl-clear-cells ()
  "Clear the input and output cells of the current buffer."
  (interactive)
  (jupyter-repl-without-continuation-prompts
   (let ((inhibit-read-only t))
     (save-excursion
       (goto-char (point-min))
       (when (get-text-property (point) 'jupyter-banner)
         (goto-char (next-single-property-change (point) 'jupyter-banner)))
       (delete-region (point) (point-max))
       (jupyter-repl-insert-prompt 'in))))
  (goto-char (point-max)))

(defun jupyter-repl-clear-input ()
  "Clear the contents of the input cell."
  (interactive)
  (goto-char (point-max))
  (delete-region
   (jupyter-repl-cell-code-beginning-position)
   (point-max)))

;;; Handlers

(defun jupyter-repl-history-add (code)
  "Add CODE as the newest element in the REPL history."
  ;; Ensure the newest element is actually the newest element and not the most
  ;; recently navigated history element.
  (while (not (eq (ring-ref jupyter-repl-history -1) 'jupyter-repl-history))
    (ring-insert jupyter-repl-history (ring-remove jupyter-repl-history)))
  ;; Remove the second to last element when the ring is full to preserve the
  ;; sentinel.
  (when (eq (ring-length jupyter-repl-history)
            (ring-size jupyter-repl-history))
    (ring-remove jupyter-repl-history -2))
  (ring-remove+insert+extend jupyter-repl-history code))

(defun jupyter-repl-execute-cell (&optional client)
  "Execute the last REPL cell of CLIENT.
Return the `jupyter-request' representing the executed code."
  (or client (setq client jupyter-current-client))
  (cl-check-type client jupyter-repl-client)
  (jupyter-with-repl-buffer client
    (jupyter-repl-truncate-buffer)
    (save-excursion
      (goto-char (jupyter-repl-cell-code-beginning-position))
      (run-hooks 'jupyter-repl-cell-pre-send-hook))
    (let ((code (string-trim (jupyter-repl-cell-code) "[\r\n]+")))
      (jupyter-run-with-client client
        (jupyter-mlet* ((req (jupyter-execute-request
                              :code code
                              ;; Handle empty code cells as just an update
                              ;; of the prompt number
                              :silent (and (= (length code) 0) t))))
          (jupyter-repl-without-continuation-prompts
           (jupyter-repl-cell-mark-busy)
           (jupyter-repl-finalize-cell req)
           (jupyter-repl-history-add code)
           (jupyter-repl-insert-prompt
            'in (1+ (oref client execution-count))))
          (save-excursion
            (jupyter-repl-backward-cell)
            (run-hooks 'jupyter-repl-cell-post-send-hook))
          (jupyter-sent (jupyter-return req)))))))

(cl-defmethod jupyter-handle-payload ((_source (eql set_next_input)) pl
                                      &context (major-mode jupyter-repl-mode))
  (goto-char (point-max))
  (jupyter-repl-previous-cell)
  (jupyter-repl-replace-cell-code (plist-get pl :text)))

(cl-defmethod jupyter-handle-execute-reply ((client jupyter-repl-client) _req msg)
  (jupyter-with-repl-buffer client
    (jupyter-with-message-content msg (payload)
      (when payload
        (jupyter-handle-payload payload)))))

(cl-defmethod jupyter-handle-execute-result ((client jupyter-repl-client) req msg)
  ;; Only handle our results
  (when req
    (jupyter-repl-append-output client req
      (jupyter-repl-insert-prompt 'out)
      (jupyter-with-message-content msg (data metadata)
        (jupyter-insert data metadata)))))

(cl-defmethod jupyter-handle-display-data ((client jupyter-repl-client) req msg)
  (let ((clear (prog1 (oref client wait-to-clear)
                 (oset client wait-to-clear nil)))
        (req (if (equal (jupyter-message-parent-type msg) "comm_msg")
                 ;; For comm messages which produce a display_data message,
                 ;; the request is assumed to be the most recently completed
                 ;; one.
                 (jupyter-with-repl-buffer client
                   (save-excursion
                     (goto-char (point-max))
                     (jupyter-repl-previous-cell 2)
                     (jupyter-repl-cell-request)))
               req)))
    (jupyter-repl-append-output client req
      (jupyter-with-message-content msg (data metadata transient)
        (cl-destructuring-bind (&key display_id &allow-other-keys)
            transient
          (if display_id
              (jupyter-insert display_id data metadata)
            (let ((inhibit-redisplay (not debug-on-error)))
              (when clear
                (jupyter-repl-clear-last-cell-output client)
                ;; Prevent slight flickering of prompt margin and text, this is
                ;; needed in addition to `inhibit-redisplay'.  It also seems that
                ;; it can be placed anywhere within this let and it will prevent
                ;; flickering.
                (sit-for 0.1 t))
              (jupyter-insert data metadata))))))))

(cl-defmethod jupyter-handle-update-display-data ((client jupyter-repl-client) _req msg)
  (jupyter-with-message-content msg (data metadata transient)
    (cl-destructuring-bind (&key display_id &allow-other-keys)
        transient
      (unless display_id
        (error "No display ID in `update_display_data' message"))
      (jupyter-with-repl-buffer client
        (jupyter-update-display display_id data metadata)))))

(defun jupyter-repl-clear-last-cell-output (client)
  "In CLIENT's REPL buffer, clear the output of the last completed cell."
  (jupyter-with-repl-buffer client
    (goto-char (point-max))
    (jupyter-repl-previous-cell 2)
    (delete-region (1+ (jupyter-repl-cell-end-position))
                   (progn
                     (jupyter-repl-next-cell)
                     (point)))))

(cl-defmethod jupyter-handle-clear-output ((client jupyter-repl-client) _req msg)
  (unless (oset client wait-to-clear
                (jupyter-with-message-content msg (wait)
                  (eq wait t)))
    (cond
     ((equal (jupyter-message-parent-type msg) "comm_msg")
      (with-current-buffer (jupyter-get-buffer-create "output")
        (erase-buffer)))
     (t
      (jupyter-repl-clear-last-cell-output client)))))

(cl-defmethod jupyter-handle-status ((client jupyter-repl-client) req msg)
  (when (equal "idle"
               (jupyter-with-message-content msg (execution_state)
                 execution_state))
    (jupyter-with-repl-buffer client
      (save-excursion
        (when (ignore-errors
                (progn (jupyter-repl-goto-cell req) t))
          (jupyter-repl-cell-unmark-busy))
        ;; Update the cell count and reset the prompt
        (goto-char (point-max))
        (jupyter-repl-update-cell-count (oref client execution-count)))))
  (force-mode-line-update))

(defun jupyter-repl-display-other-output (client stream text)
  "Display output not originating from CLIENT.
STREAM is the name of a stream which will be used to select the
buffer to display TEXT."
  (let* ((bname (buffer-name (oref client buffer)))
         (stream-buffer
          (concat (substring bname 0 (1- (length bname))) "-" stream "*")))
    ;; FIXME: Reset this on the next request
    (jupyter-with-display-buffer stream-buffer nil
      (let ((pos (point)))
        (jupyter-insert-ansi-coded-text text)
        (fill-region pos (point)))
      (jupyter-display-current-buffer-reuse-window))))

(cl-defmethod jupyter-handle-stream ((client jupyter-repl-client) req msg)
  (jupyter-with-message-content msg (name text)
    (if (null req)
        (jupyter-repl-display-other-output client name text)
      (cond
       ((equal (jupyter-message-parent-type
                (jupyter-request-last-message req))
               "comm_msg")
        (jupyter-with-display-buffer "output" req
          (jupyter-insert-ansi-coded-text text)
          (jupyter-display-current-buffer-reuse-window)))
       (t
        (jupyter-repl-append-output client req
          (jupyter-insert-ansi-coded-text text)))))))

(cl-defmethod jupyter-handle-error ((client jupyter-repl-client) req msg)
  (when req
    (jupyter-with-message-content msg (traceback)
      (cond
       ((equal (jupyter-message-parent-type msg) "comm_msg")
        (jupyter-display-traceback traceback))
       (t
        (jupyter-repl-append-output client req
          (jupyter-with-insertion-bounds
              beg end (jupyter-insert-ansi-coded-text
                       (concat (mapconcat #'identity traceback "\n") "\n"))
            (font-lock-prepend-text-property
             beg end 'font-lock-face 'jupyter-repl-traceback))))))))

(defun jupyter-repl-history--rotate (n)
  "Rotate the REPL history ring N times.
The direction of rotation is determined by the sign of N.  For N
positive rotate to newer history elements, for N negative rotate
to older elements.

Return nil if the sentinel value is found before completing the
required number of rotations, otherwise return the element
rotated to, i.e. the one at index 0."
  (let (ifun cidx ridx)
    (if (> n 0)
        (setq ifun 'ring-insert cidx -1 ridx -1)
      (setq ifun 'ring-insert-at-beginning cidx 1 ridx 0))
    (cl-loop
     repeat (abs n)
     ;; Check that the next index to rotate to is not the sentinel
     if (eq (ring-ref jupyter-repl-history cidx) 'jupyter-repl-history)
     return nil else do
     ;; if it isn't, remove an element at RIDX and insert it using IFUN back
     ;; into the history ring, thereby rotating the history
     (funcall ifun jupyter-repl-history
              (ring-remove jupyter-repl-history ridx))
     ;; after N successful rotations, return the element rotated to
     finally return (let ((el (ring-ref jupyter-repl-history 0)))
                      (unless (eq el 'jupyter-repl-history)
                        el)))))

(defun jupyter-repl-history--match-input (regexp arg)
  "Return the index of the ARGth REGEXP match.
Or nil, on failure.  If ARG is positive, search backward from the most
recent history element.  If negative, search forward through items
previously visited during this input session.  If ARG is zero, do
nothing."
  ;; Adapted from `comint-previous-matching-input-string-position'
  (let* ((direction (if (> arg 0) +1 -1))
         (i (if (= direction -1) 0 -1)) ; adjust for initial increment
         (failed (zerop arg))
         ;;
         code)
    ;; Search ARG times
    (while (not (or failed (zerop arg)))
      (while (not (or (setq code (ring-ref jupyter-repl-history
                                           (cl-incf i direction))
                            failed (eq 'jupyter-repl-history code))
                      (string-match-p regexp code))))
      (setq arg (- arg direction)))
    (unless failed i)))

(defun jupyter-repl-history-previous-matching (&optional n)
  "Search input history for the input pending before point.
On success, replace the current input with the matching code element
while preserving and returning point.  Ding on failure.  If N is negative,
find the Nth next match; if positive, the Nth previous.  If N is zero or
nil, pretend it's one."
  ;; Adapted from: `comint-previous-matching-input-from-input' and friends
  (interactive "p")
  (when (or (null n) (zerop n)) (setq n 1))
  (let ((opoint (point))
        (code (jupyter-repl-cell-code))
        (is-prev (> n 0))
        (input-string (buffer-substring
                       (jupyter-repl-cell-code-beginning-position) (point)))
        found)
    ;; Look past an initial duplicate
    (when (equal code (ring-ref jupyter-repl-history (if is-prev 0 -1)))
      (cl-incf n (if is-prev 1 -1)))
    (if (not (setq found (jupyter-repl-history--match-input
                          (concat "^" (regexp-quote input-string)) n)))
        (user-error "No %s matching input" (if is-prev "earlier" "later"))
      (setq code (ring-ref jupyter-repl-history found))
      (jupyter-repl-history--rotate (- found))
      (jupyter-repl-replace-cell-code code)
      (goto-char opoint))))

(defun jupyter-repl-history-next-matching (&optional n)
  "Search existing history session for an element matching input.
Only consider the text before point.  If N is negative, find the Nth
previous match, otherwise the Nth next.  If N is zero or nil, make it
one.  \"Existing history session\" means those history elements already
visited while forming the current input."
  (interactive "p")
  (when (or (null n) (zerop n)) (setq n 1))
  (jupyter-repl-history-previous-matching (- n)))

(defun jupyter-repl-history-next (&optional n)
  "Go to the next history element.
Navigate through the REPL history to the next (newer) history
element and insert it as the last code cell.  For N positive move
forward in history that many times.  If N is negative, move to
older history elements."
  (interactive "p")
  (or n (setq n 1))
  (if (< n 0) (jupyter-repl-history-previous (- n))
    (goto-char (point-max))
    (let ((code (jupyter-repl-history--rotate n)))
      (if (and (null code) (equal (jupyter-repl-cell-code) ""))
          (error "End of history")
        (if (null code)
            ;; When we have reached the last history element in the forward
            ;; direction and the cell code is not empty, make it empty.
            (jupyter-repl-replace-cell-code "")
          (jupyter-repl-replace-cell-code code))))))

(defun jupyter-repl-history-previous (&optional n)
  "Go to the previous history element.
Similar to `jupyter-repl-history-next' but for older history
elements.  If N is negative in this case, move to newer history
elements."
  (interactive "p")
  (or n (setq n 1))
  (if (< n 0) (jupyter-repl-history-next (- n))
    (goto-char (point-max))
    (unless (equal (jupyter-repl-cell-code)
                   (ring-ref jupyter-repl-history 0))
      (setq n (1- n)))
    (let ((code (jupyter-repl-history--rotate (- n))))
      (if (null code)
          (error "Beginning of history")
        (jupyter-repl-replace-cell-code code)))))

(cl-defmethod jupyter-handle-history-reply ((client jupyter-repl-client) _req msg)
  (jupyter-with-repl-buffer client
    (cl-loop for elem across (jupyter-with-message-content msg (history) history)
             for input-output = (aref elem 2)
             do (ring-remove+insert+extend jupyter-repl-history input-output))))

(cl-defmethod jupyter-handle-is-complete-reply ((client jupyter-repl-client) _req msg)
  (jupyter-with-repl-buffer client
    (jupyter-with-message-content msg (status indent)
      ;; `jupyter-run-soon' is used here so that the waiting done in
      ;; `jupyter-repl-ret' completes before a cell is executed.
      (pcase status
        ("complete"
         (jupyter-run-soon
           (jupyter-repl-execute-cell client)))
        ("incomplete"
         (insert "\n")
         (if (= (length indent) 0) (jupyter-repl-indent-line)
           (insert indent)))
        ("invalid"
         ;; Force an execute to produce a traceback
         (jupyter-run-soon
           (jupyter-repl-execute-cell client)))
        ("unknown"
         ;; Let the kernel decide if the code is complete
         (jupyter-run-soon
           (jupyter-repl-execute-cell client)))))))

(defun jupyter-repl--insert-banner-and-prompt (client)
  (jupyter-with-repl-buffer client
    (goto-char (point-max))
    (unless (jupyter-repl-cell-finalized-p)
      (jupyter-repl-finalize-cell nil))
    (jupyter-repl-newline)
    (jupyter-repl-insert-banner
     (plist-get (jupyter-kernel-info client) :banner))
    (jupyter-repl-insert-prompt 'in)
    (jupyter-repl-update-cell-count 1)))

(cl-defmethod jupyter-handle-shutdown-reply ((client jupyter-repl-client) _req msg)
  (jupyter-with-repl-buffer client
    (jupyter-repl-without-continuation-prompts
     (goto-char (point-max))
     (let ((shutdown-handled-p (jupyter-repl-cell-finalized-p)))
       (unless (jupyter-repl-cell-finalized-p)
         (jupyter-repl-finalize-cell nil))
       ;; Only run the following once.  The Python kernel sends a shutdown-reply
       ;; on both the shell and iopub which is mainly the reason why this is
       ;; needed.
       (unless shutdown-handled-p
         (jupyter-repl-newline)
         (jupyter-repl-newline)
         (jupyter-with-message-content msg (restart)
           ;; TODO: Add a slot mentioning that the kernel is shutdown so that we can
           ;; block sending requests or delay until it has restarted.
           (insert (propertize (concat "kernel " (if restart "restart" "shutdown"))
                               'read-only t 'font-lock-face 'warning))
           (jupyter-repl-newline)
           (when restart
             (jupyter-repl--insert-banner-and-prompt client))))))))

(defun jupyter-repl-send ()
  "Send the current cell code to the kernel.
As opposed to `jupyter-repl-ret' this sends the code immediately
without considering the syntactical correctness of the code."
  (interactive)
  (jupyter-repl-ret 'force))

(defun jupyter-repl-ret (&optional force)
  "Send the current cell code to the kernel.
If `point' is before the last cell in the REPL buffer move to
`point-max', i.e. move to the last cell.  Otherwise if `point' is
at some position within the last cell, either insert a newline or
ask the kernel to execute the cell code depending on the kernel's
response to an is_complete_request.

If a prefix argument is given, FORCE the kernel to execute the
current cell code without sending an is_complete_request.  See
`jupyter-repl-use-builtin-is-complete' for yet another way to
execute the current cell."
  (interactive "P")
  (condition-case nil
      (let ((cell-beginning (save-excursion
                              (goto-char (point-max))
                              (jupyter-repl-cell-beginning-position))))
        (if (< (point) cell-beginning)
            (goto-char (point-max))
          (unless (jupyter-repl-connected-p)
            (error "Kernel not alive"))
          (unless (eq this-command 'jupyter-repl-send)
            ;; NOTE: kernels allow execution requests to queue up, but
            ;; we prevent sending a request when the kernel is busy
            ;; because of the is_complete_request.  Some kernels don't
            ;; respond to this request when the kernel is busy.
            (when (and (jupyter-kernel-busy-p jupyter-current-client)
                       (not jupyter-repl-allow-RET-when-busy))
              (error "Kernel busy")))
          (cond
           (force (jupyter-repl-execute-cell))
           (jupyter-repl-without-is-complete
            (newline)
            (jupyter-repl-indent-line))
           ((or jupyter-repl-use-builtin-is-complete
                (and jupyter-repl-allow-RET-when-busy
                     (jupyter-kernel-busy-p jupyter-current-client)))
            (if (save-excursion
                  (goto-char (point-max))
                  (string-empty-p
                   (buffer-substring
                    (line-beginning-position)
                    (point))))
                (let ((client jupyter-current-client))
                  (jupyter-run-soon
                    (jupyter-repl-execute-cell client)))
              (newline)
              (jupyter-repl-indent-line)))
           (t
            (condition-case nil
                (jupyter-run-with-client jupyter-current-client
                  (jupyter-idle
                   (jupyter-is-complete-request
                    :code (jupyter-repl-cell-code)
                    :handlers '("is_complete_reply"))
                   jupyter-repl-maximum-is-complete-timeout))
              (jupyter-timeout-before-idle
               (message "\
Kernel did not respond to is_complete_request, using built-in is_complete.
Reset `jupyter-repl-use-builtin-is-complete' to nil if this is only temporary.")
               (setq jupyter-repl-use-builtin-is-complete t)
               (jupyter-repl-ret force)))))))
    (beginning-of-buffer
     ;; No cells in the current buffer, just insert one
     (jupyter-repl-insert-prompt 'in))))

(cl-defgeneric jupyter-indent-line ()
  (call-interactively #'indent-for-tab-command))

(defun jupyter-repl-indent-line ()
  "Indent the line according to the language of the REPL."
  (let ((indent-tabs indent-tabs-mode))
    (when-let* ((pos (and (jupyter-repl-cell-line-p)
                          (jupyter-repl-cell-code-position)))
                (code (jupyter-repl-cell-code))
                (replacement
                 (jupyter-with-repl-lang-buffer
                   (if indent-tabs
                       (unless indent-tabs-mode
                         (indent-tabs-mode 1))
                     (when indent-tabs-mode
                       (indent-tabs-mode -1)))
                   (insert code)
                   (goto-char pos)
                   (let ((tick (buffer-chars-modified-tick)))
                     (jupyter-indent-line)
                     (unless (eq tick (buffer-chars-modified-tick))
                       ;; FIXME: Why the 1-.  It seems necessary for
                       ;; the Python kernel.  Maybe to do with
                       ;; prompts?
                       (setq pos (1- (point)))
                       (current-buffer))))))
      ;; Don't modify the buffer when unnecessary, this allows
      ;; `company-indent-or-complete-common' to work.
      (when replacement
        (jupyter-repl-replace-cell-code replacement)
        (goto-char (+ pos (jupyter-repl-cell-code-beginning-position)))))))

;;; Buffer change functions

(defun jupyter-repl-yank-handle-field-property (val beg end)
  "If VAL is not cell-code, remove the field property between BEG and END.
Yanking text into a REPL cell normally removes the field
property (see `yank-excluded-properties') but this property is
added in `jupyter-repl-after-change' to mark text in an input cell.

The problem is that the after change functions run *before*
`insert-for-yank' removes the field property.  This function is
added to `yank-handled-properties' to prevent the removal of
field when the associated text is part of the input to a REPL
cell."
  ;; Assume that text with a field value of cell-code is due to
  ;; `jupyter-repl-mark-as-cell-code'.
  (unless (eq val 'cell-code)
    (remove-text-properties beg end '(field))))

(defun jupyter-repl-insert-continuation-prompts (bound)
  "Insert continuation prompts if needed, stopping at BOUND.
Return the new BOUND since inserting continuation prompts may add
more characters than were initially in the buffer.

If `jupyter-repl-inhibit-continuation-prompts' is non-nil return
BOUND without inserting any continuation prompts."
  (if jupyter-repl-inhibit-continuation-prompts
      bound
    (setq bound (set-marker (make-marker) bound))
    (set-marker-insertion-type bound t)
    ;; Don't record these changes.  They add unnecessary undo
    ;; information that interferes with undo.
    (let ((buffer-undo-list t))
      (while (and (< (point) bound)
                  (search-forward "\n" bound 'noerror))
        ;; Delete the newline that is re-added by prompt insertion
        ;; FIXME: Why not just overlay the newline?
        (delete-char -1)
        (jupyter-repl-insert-prompt 'continuation)))
    (prog1 (marker-position bound)
      (set-marker bound nil))))

(defun jupyter-repl-mark-as-cell-code (beg end)
  "Add the field property to text between (BEG . END) if within a code cell."
  ;; Handle field boundary at the front of the cell code
  (when (< beg end)
    (when (= beg (jupyter-repl-cell-code-beginning-position))
      (put-text-property beg (1+ beg) 'front-sticky t))
    (when (text-property-not-all beg end 'field 'cell-code)
      (font-lock-fillin-text-property beg end 'field 'cell-code))))

(defun jupyter-repl-do-after-change (beg end len)
  "Call `jupyter-repl-after-change' when the current cell code is changed.
`jupyter-repl-after-change' is only called when BEG is a position
on a `jupyter-repl-cell-line-p'.  BEG, END, and LEN have the same
meaning as in `after-change-functions'."
  (when (eq major-mode 'jupyter-repl-mode)
    (with-demoted-errors "Jupyter error after buffer change: %S"
      (save-restriction
        ;; Take into account insertions that may have the buffer narrowed since
        ;; functions like `jupyter-repl-cell-code-beginning-position' need to
        ;; look at parts of the buffer not necessarily within the narrowed
        ;; region.  See #38.
        ;;
        ;; TODO: Does it really make sense to widen the buffer? To get around
        ;; this, how can functions like
        ;; `jupyter-repl-cell-code-beginning-position' and
        ;; `jupyter-repl-cell-line-p' only rely on the `field' text property?
        (widen)
        (when (save-excursion
                (goto-char beg)
                (jupyter-repl-cell-line-p))
          (cond
           ((= len 0)
            (jupyter-repl-after-change 'insert beg end))
           ((and (= beg end) (not (zerop len)))
            (jupyter-repl-after-change 'delete beg len))
           ;; Text property changes
           ((= (- end beg) len)
            ;; Revert changes made by `insert-for-yank'.  See #14.
            (when (and (= len 1)
                       (get-text-property beg 'rear-nonsticky)
                       (= end (jupyter-repl-cell-end-position)))
              (remove-text-properties beg end '(rear-nonsticky))))
           ;; Post change inserted text in the region
           ((> (- end beg) len)
            (jupyter-repl-after-change 'insert beg end))
           ;; Post change deleted text
           (t
            ;; FIXME: This is probably wrong.
            (jupyter-repl-after-change 'delete beg (- len (- end beg))))))))))

(cl-defgeneric jupyter-repl-after-change (_type _beg _end-or-len)
  "Called from the `after-change-functions' of a REPL buffer.
Modify the text just inserted or deleted.  TYPE is either insert
or delete to signify if the change was due to insertion or
deletion of text.  BEG is always the beginning of the insertion or
deletion.  END-OR-LEN is the end of the insertion when TYPE is
insert and is the length of the deleted text when TYPE is delete.

The `after-change-functions' of the REPL buffer are only called
for changes to input cells and not for output generated by the
kernel.

Note, the overriding method should call `cl-call-next-method'.

Also note, any buffer narrowing will be temporarily removed when
this method is called."
  nil)

(cl-defmethod jupyter-repl-after-change ((_type (eql insert)) beg end)
  (goto-char beg)
  ;; Avoid doing anything on self insertion
  (unless (and (= (point) (1- end))
               (not (eq (char-after) ?\n)))
    (setq end (jupyter-repl-insert-continuation-prompts end)))
  (jupyter-repl-mark-as-cell-code beg end)
  (goto-char end))

(cl-defmethod jupyter-repl-after-change ((_type (eql delete)) beg _len)
  ;; Ensure that the `front-sticky' property at the beginning of cell code is
  ;; added after deleting text at the beginning of a cell.
  (jupyter-repl-mark-as-cell-code beg (min (point-max) (+ beg 1))))

(defvar jupyter-repl-interaction-mode)

(defun jupyter-repl--deactivate-interaction-buffers ()
  (cl-loop
   with client = jupyter-current-client
   for buffer in (buffer-list)
   do (with-current-buffer buffer
        (when (and jupyter-repl-interaction-mode
                   (eq jupyter-current-client client))
          (jupyter-repl-interaction-mode -1)))))

(defun jupyter-repl-kill-buffer-query-function ()
  "Ask to shutdown the kernel before killing a REPL buffer.
If the current client is not connected to a kernel, kill the
buffer.  If the current client is connected to a kernel, only
kill the buffer if the user wants to also shutdown the kernel.

Before shutting down the kernel, deactivate
`jupyter-repl-interaction-mode' in all buffers associated with
the REPL."
  (when (eq major-mode 'jupyter-repl-mode)
    (let ((connected-p (jupyter-connected-p jupyter-current-client)))
      (or (not connected-p)
          (when (y-or-n-p (format "Jupyter REPL (%s) still connected.  Shutdown kernel? "
                                  (buffer-name (current-buffer))))
            (jupyter-repl--deactivate-interaction-buffers)
            (jupyter-shutdown-kernel jupyter-current-client)
            t)))))

(defun jupyter-repl-error-before-major-mode-change ()
  "Error if attempting to change the `major-mode' in a REPL buffer."
  (when (eq major-mode 'jupyter-repl-mode)
    (error "Attempting to change `major-mode' in the REPL buffer!")))

(defun jupyter-repl-preserve-window-margins (&optional window)
  "Ensure that the margins of a REPL window are present.
If WINDOW is showing a REPL buffer and the margins are not set to
`jupyter-repl-prompt-margin-width', set them to the proper
value."
  ;; NOTE: Sometimes the margins will disappear after the window configuration
  ;; changes which is why `window-configuration-change-hook' is not used.
  (when (and (eq major-mode 'jupyter-repl-mode)
             (let ((margins (window-margins window)))
               (not (and (consp margins)
                         (car margins)
                         (= (car margins) jupyter-repl-prompt-margin-width)))))
    (set-window-buffer window (current-buffer))))

;;; Completion

(cl-defmethod jupyter-code-context ((_type (eql completion))
                                    &context (major-mode jupyter-repl-mode))
  (list (jupyter-repl-cell-code)
        (1- (jupyter-repl-cell-code-position))))

(cl-defmethod jupyter-completion-prefix (&context (major-mode jupyter-repl-mode))
  (and (not (get-text-property (point) 'read-only))
       (cl-call-next-method)))

;;; Evaluation

(cl-defmethod jupyter-eval-string (str &context (jupyter-current-client jupyter-repl-client)
                                       &optional insert beg end)
  (cond
   (jupyter-repl-echo-eval-p
    (jupyter-with-repl-buffer jupyter-current-client
      (goto-char (point-max))
      (let ((code (jupyter-repl-cell-code)))
        (jupyter-repl-replace-cell-code str)
        (prog1 (jupyter-repl-execute-cell)
          (jupyter-repl-replace-cell-code code)))))
   (t
    (jupyter-run-with-client jupyter-current-client
      (jupyter-sent
       (let ((req
              (jupyter-execute-request
               :code str
               :store-history jupyter-repl-echo-eval-p
               :handlers '("input_request"))))
         (if (and jupyter-repl-echo-eval-p
                  (get-buffer-window
                   (oref jupyter-current-client buffer)
                   'visible))
             req
           ;; Add callbacks to display evaluation output in pop-up
           ;; buffers either when we aren't copying the input to a
           ;; REPL cell or, if we are, when the REPL buffer isn't
           ;; visible.
           ;;
           ;; Make sure we do this in the original buffer where
           ;; STR originated from when BEG and END are non-nil.
           (jupyter-message-subscribed
            req (jupyter-eval-callbacks insert beg end)))))))))

;;; Kernel management

(defun jupyter-repl--get-client ()
  (or jupyter-current-client
      ;; Also allow this command to be called from an Org mode buffer by
      ;; selecting a client based on the REPL buffer.
      (buffer-local-value
       'jupyter-current-client
       (or (jupyter-repl-completing-read-repl-buffer)
           (error "No REPLs available")))))

(defun jupyter-repl-interrupt-kernel (&optional client)
  "Interrupt the kernel CLIENT is connected to, if possible.
CLIENT defaults to `jupyter-current-client'."
  (interactive)
  (or client (setq client (jupyter-repl--get-client)))
  (cl-check-type client jupyter-repl-client)
  (jupyter-interrupt-kernel client))

(defun jupyter-repl-shutdown-kernel (&optional client)
  "Shutdown the kernel CLIENT is connected to.
CLIENT defaults to `jupyter-current-client'."
  (interactive)
  (or client (setq client (jupyter-repl--get-client)))
  (cl-check-type client jupyter-repl-client)
  ;; FIXME: The heartbeat channel is no longer used.
  (jupyter-hb-pause client)
  (jupyter-with-repl-buffer client
    (setq jupyter-repl-use-builtin-is-complete nil))
  (message "Shutting down kernel...")
  (when (jupyter-kernel-alive-p client)
    (jupyter-shutdown-kernel client)))

(defun jupyter-repl-restart-kernel (&optional client)
  "Restart the kernel CLIENT is connected to.
CLIENT defaults to `jupyter-current-client'."
  (interactive)
  (or client (setq client (jupyter-repl--get-client)))
  (cl-check-type client jupyter-repl-client)
  (jupyter-hb-pause client)
  (jupyter-with-repl-buffer client
    (setq jupyter-repl-use-builtin-is-complete nil))
  (if (jupyter-kernel-alive-p client)
      (jupyter-restart-kernel client)
    (message "Starting dead kernel...")
    (jupyter-kernel-action client #'jupyter-launch))
  (jupyter-repl--insert-banner-and-prompt client)
  (jupyter-hb-unpause client))

;;; Isearch
;; Adapted from isearch in `comint', see `comint-history-isearch-search' for
;; details

(defun jupyter-repl-isearch-setup ()
  "Setup Isearch to search through the input history."
  (setq-local isearch-search-fun-function
              #'jupyter-repl-history-isearch-search)
  (setq-local isearch-wrap-function
              #'jupyter-repl-history-isearch-wrap)
  (setq-local isearch-push-state-function
              #'jupyter-repl-history-isearch-push-state))

;; Adapted from `comint-history-isearch-search'
(defun jupyter-repl-history-isearch-search ()
  "Return a search function to search through a REPL's input history."
  (lambda (string bound noerror)
    (let ((search-fun (isearch-search-fun-default)) found)
      (setq isearch-lazy-highlight-start-limit
            (jupyter-repl-cell-beginning-position))
      (or
       ;; 1.  First try searching in the initial cell text
       (funcall search-fun string
                (or bound
                    (unless isearch-forward
                      (jupyter-repl-cell-code-beginning-position)))
                noerror)
       ;; 2.  If the above search fails, start putting next/prev history
       ;; elements in the cell successively, and search the string in them.  Do
       ;; this only when bound is nil (i.e. not while lazy-highlighting search
       ;; strings in the current cell text).
       (unless bound
         (condition-case err
             (progn
               (while (not found)
                 (cond (isearch-forward
                        ;; `jupyter-repl-history-next' clears the cell if the
                        ;; last element is the sentinel, prevent that.
                        (if (eq (ring-ref jupyter-repl-history -1)
                                'jupyter-repl-history)
                            (error "End of history")
                          (jupyter-repl-history-next))
                        (goto-char (jupyter-repl-cell-code-beginning-position)))
                       (t
                        (jupyter-repl-history-previous)
                        (goto-char (point-max))))
                 ;; After putting the next/prev history element, search the
                 ;; string in them again, until an error is thrown at the
                 ;; beginning/end of history.
                 (setq found (funcall search-fun string
                                      (unless isearch-forward
                                        (jupyter-repl-cell-code-beginning-position))
                                      'noerror)))
               ;; Return point of the new search result
               (point))
           (error
            (unless noerror
              (signal (car err) (cdr err))))))))))

(defun jupyter-repl-history-isearch-wrap ()
  "Wrap the input history search when search fails.
Go to the oldest history element for a forward search or to the
newest history element for a backward search."
  (jupyter-repl-history--rotate
   (* (if isearch-forward -1 1)
      (ring-length jupyter-repl-history)))
  (jupyter-repl-replace-cell-code (ring-ref jupyter-repl-history 0))
  (goto-char (if isearch-forward (jupyter-repl-cell-code-beginning-position)
               (point-max))))

(defun jupyter-repl-history-isearch-push-state ()
  "Save a function restoring the state of input history search.
Save the element at index 0 in `jupyter-repl-history'.  When
restoring the state, the `jupyter-repl-history' ring is rotated,
in the appropriate direction, to the saved element."
  (let ((code (jupyter-repl-cell-code)))
    (cond
     ((equal code (ring-ref jupyter-repl-history 0))
      (let ((elem (ring-ref jupyter-repl-history 0)))
        (lambda (_cmd)
          (when isearch-wrapped
            (jupyter-repl-history--rotate
             (* (if isearch-forward 1 -1)
                (ring-length jupyter-repl-history))))
          (let ((dir (if isearch-forward -1 1)))
            (while (not (eq (ring-ref jupyter-repl-history 0) elem))
              (jupyter-repl-history--rotate dir)))
          (jupyter-repl-replace-cell-code (ring-ref jupyter-repl-history 0)))))
     (t
      (let ((elem code))
        (lambda (_cmd)
          (jupyter-repl-replace-cell-code elem)))))))

;;; `jupyter-repl-mode'

(defun jupyter-repl-scratch-buffer ()
  "Switch to a scratch buffer connected to the current REPL in another window.
Return the buffer switched to."
  (interactive)
  (if (jupyter-repl-connected-p)
      (let* ((client jupyter-current-client)
             (name (replace-regexp-in-string "^*jupyter-repl"
					     "*jupyter-scratch"
					     (buffer-name) (oref client buffer))))
        (unless (get-buffer name)
          (with-current-buffer (get-buffer-create name)
            (funcall (jupyter-kernel-language-mode client))
            (jupyter-repl-associate-buffer client)
            (insert
             (substitute-command-keys
              "Jupyter scratch buffer for evaluation.
\\[jupyter-eval-line-or-region] to evaluate the line or region.
\\[jupyter-eval-buffer] to evaluate the whole buffer.
\\[jupyter-repl-pop-to-buffer] to show the REPL buffer."))
            (comment-region (point-min) (point-max))
            (insert "\n\n")))
        (switch-to-buffer-other-window name))
    (error "Not in a valid REPL buffer")))

(defvar jupyter-repl-mode-map
  (let ((map (make-sparse-keymap)))
    (define-key map "q" nil)
    (define-key map [remap backward-sentence] #'jupyter-repl-backward-cell)
    (define-key map [remap forward-sentence] #'jupyter-repl-forward-cell)
    (define-key map (kbd "RET") #'jupyter-repl-ret)
    (define-key map (kbd "S-RET") #'jupyter-repl-send)
    (define-key map (kbd "<return>") #'jupyter-repl-ret)
    (define-key map (kbd "S-<return>") #'jupyter-repl-send)
    (define-key map (kbd "M-n") #'jupyter-repl-history-next)
    (define-key map (kbd "M-p") #'jupyter-repl-history-previous)
    (define-key map (kbd "C-c C-o") #'jupyter-repl-clear-cells)
    (define-key map (kbd "C-c C-u") #'jupyter-repl-clear-input)
    (define-key map (kbd "C-c M-r") #'jupyter-repl-history-previous-matching)
    (define-key map (kbd "C-c M-s") #'jupyter-repl-history-next-matching)
    map))

(put 'jupyter-repl-mode 'mode-class 'special)
(define-derived-mode jupyter-repl-mode fundamental-mode
  "Jupyter-REPL"
  "A Jupyter REPL major mode."
  (cl-check-type jupyter-current-client jupyter-repl-client)
  ;; This is a better setting for rendering language banners.
  (setq-local show-trailing-whitespace nil)
  ;; This is a better setting when rendering HTML tables
  (setq-local truncate-lines t)
  (setq-local indent-line-function #'jupyter-repl-indent-line)
  (setq-local left-margin-width jupyter-repl-prompt-margin-width)
  (setq-local yank-handled-properties
              (append '((field . jupyter-repl-yank-handle-field-property))
                      yank-handled-properties))
  (setq-local yank-excluded-properties (remq 'field yank-excluded-properties))
  ;; Initialize a buffer using the major-mode correponding to the kernel's
  ;; language.  This will be used for indentation and to capture font lock
  ;; properties.
  (let* ((info (jupyter-kernel-info jupyter-current-client))
         (language (plist-get (plist-get info :language_info) :name)))
    (jupyter-load-language-support jupyter-current-client)
    (cl-destructuring-bind (mode syntax)
        (jupyter-kernel-language-mode-properties jupyter-current-client)
      (setq jupyter-repl-lang-mode mode)
      (setq jupyter-repl-lang-buffer
            (get-buffer-create
             (format " *jupyter-repl-lang-%s*" language)))
      (set-syntax-table syntax)
      (jupyter-with-repl-lang-buffer
        (unless (eq major-mode mode)
          (funcall mode))))
    ;; Get history from kernel
    (setq jupyter-repl-history
          (make-ring (1+ jupyter-repl-history-maximum-length)))
    ;; The sentinel value keeps track of the newest/oldest elements of the
    ;; history since next/previous navigation is implemented by rotations on the
    ;; ring.
    (ring-insert jupyter-repl-history 'jupyter-repl-history)
    (jupyter-run-with-client jupyter-current-client
      (jupyter-sent
       (jupyter-history-request
        :n jupyter-repl-history-maximum-length
        :raw t
        :unique t
        :handlers '(not "status"))))
    (erase-buffer)
    ;; Add local hooks
    (add-hook 'kill-buffer-query-functions #'jupyter-repl-kill-buffer-query-function nil t)
    (add-hook 'kill-buffer-hook #'jupyter-repl--deactivate-interaction-buffers nil t)
    (add-hook 'after-change-functions #'jupyter-repl-do-after-change nil t)
    (add-hook 'pre-redisplay-functions #'jupyter-repl-preserve-window-margins nil t)
    (add-hook 'text-scale-mode-hook #'jupyter-repl--reset-prompts nil t)
    ;; Initialize the REPL
    (jupyter-repl-initialize-fontification)
    (jupyter-repl-isearch-setup)
    (jupyter-repl-sync-execution-state)
    (jupyter-repl-interaction-mode)
    ;; Do this last so that it runs before any other `change-major-mode-hook's.
    (add-hook 'change-major-mode-hook #'jupyter-repl-error-before-major-mode-change nil t)))

(cl-defgeneric jupyter-repl-after-init ()
  "Hook function called whenever `jupyter-repl-mode' is enabled/disabled.
You may override this function for a particular language using a
jupyter-lang &context specializer.  For example, to do something
when the language of the REPL is python the method signature
would look like

    (cl-defmethod jupyter-repl-after-init (&context (jupyter-lang python)))"
  nil)

(add-hook 'jupyter-repl-mode-hook 'jupyter-repl-after-init)

(defun jupyter-repl-font-lock-fontify-region (fontify-fun beg end &optional verbose)
  "Use FONTIFY-FUN to fontify input cells between BEG and END.
VERBOSE has the same meaning as in
`font-lock-fontify-region-function'."
  (jupyter-repl-map-cells beg end
    ;; Ensure that the buffer is narrowed to the actual cell code before calling
    ;; the REPL language's `major-mode' specific fontification functions since
    ;; those functions don't know anything about input cells or output cells and
    ;; may traverse cell boundaries.
    ;;
    ;; It is OK that we do not update BEG and END using the return value of this
    ;; function as long as the default value of
    ;; `font-lock-extend-region-functions' is used since an input cell always
    ;; starts at the beginning of a line and ends at the end of a line and does
    ;; not use the font-lock-multiline property (2018-12-20).
    (lambda ()  (funcall fontify-fun (point-min) (point-max) verbose))
    ;; Unfontify the region mainly to remove the font-lock-multiline property in
    ;; the output, e.g. added by markdown.  These regions will get highlighted
    ;; syntactically in some scenarios.
    (lambda () (font-lock-unfontify-region (point-min) (point-max))))
  `(jit-lock-bounds ,beg . ,end))

(defun jupyter-repl-syntax-propertize-function (propertize-fun beg end)
  "Use PROPERTIZE-FUN to syntax propertize text between BEG and END."
  (jupyter-repl-map-cells beg end
    ;; See note in `jupyter-repl-font-lock-fontify-region' on why the buffer
    ;; should be narrowed to the input cell before calling this function.
    (lambda () (funcall propertize-fun (point-min) (point-max)))
    ;; Treat parenthesis and string characters as punctuation when parsing the
    ;; syntax of the output.  Although we don't fontify output regions,
    ;; `syntax-ppss' still looks at the whole contents of the buffer.  If there
    ;; are unmatched parenthesis or string delimiters in the output, it will
    ;; interfere with `syntax-ppss'.  Note, this requires
    ;; `parse-sexp-lookup-properties' to be non-nil so that `syntax-ppss' will
    ;; look at the `syntax-table' property.
    (lambda ()
      (goto-char (point-min))
      (skip-syntax-forward "^()\"")
      (while (not (eobp))
        (put-text-property (point) (1+ (point)) 'syntax-table '(1 . ?.))
        (forward-char)
        (skip-syntax-forward "^()\"")))))

(cl-defgeneric jupyter-repl-initialize-fontification ()
  "Initialize fontification for the current REPL buffer."
  (let (fld frf sff spf comment)
    (jupyter-with-repl-lang-buffer
      (setq fld font-lock-defaults
            frf (or font-lock-fontify-region-function #'ignore)
            sff (or font-lock-syntactic-face-function #'ignore)
            spf (or syntax-propertize-function #'ignore)
            comment comment-start))
    ;; Set `font-lock-defaults' to a copy of the font lock defaults for the
    ;; REPL language but with a modified syntactic fontification function
    (cl-destructuring-bind (kws &optional kws-only case-fold syntax-alist
                                &rest vars)
        (or fld (list nil))
      (setq vars
            (append vars
                    (list
                     ;; See `jupyter-repl-font-lock-fontify-region'
                     (cons 'parse-sexp-lookup-properties t)
                     (cons 'syntax-propertize-function
                           (apply-partially
                            #'jupyter-repl-syntax-propertize-function spf))
                     (cons 'font-lock-fontify-region-function
                           (apply-partially
                            #'jupyter-repl-font-lock-fontify-region frf))
                     (cons 'font-lock-syntactic-face-function sff))))
      (setq-local comment-start comment)
      (setq font-lock-defaults
            (apply #'list kws kws-only case-fold syntax-alist vars)))
    (font-lock-mode)))

(defun jupyter-repl-insert-banner (banner)
  "Insert BANNER into the `current-buffer'.
Make the text of BANNER read only and apply the `shadow' face to
it."
  (jupyter-repl-without-continuation-prompts
   (insert (propertize banner
                       'read-only t 'jupyter-banner t
                       'font-lock-face 'shadow 'fontified t
                       'font-lock-fontified t))
   (jupyter-repl-newline)))

(defun jupyter-repl-sync-execution-state ()
  "Synchronize the `jupyter-current-client's kernel state.
Also update the cell count of the current REPL input prompt using
the updated state."
  (jupyter-run-with-client jupyter-current-client
    (jupyter-mlet* ((_msg (jupyter-reply
                          (jupyter-execute-request
                           :code ""
                           :silent t
                           :handlers nil)
                          ;; Waiting longer here to account for initial
                          ;; startup of the Jupyter kernel.  Sometimes
                          ;; the idle message won't be received if
                          ;; another long running execute request is
                          ;; sent right after.
                          jupyter-long-timeout))
                    (client (jupyter-get-state)))
      (unless (equal (jupyter-execution-state client) "busy")
        ;; Set the cell count and update the prompt
        (jupyter-with-repl-buffer client
          (save-excursion
            (goto-char (point-max))
            (jupyter-repl-update-cell-count
             (oref client execution-count)))))
      (jupyter-return nil))))

;;; `jupyter-repl-interaction-mode'

(defvar jupyter-repl-interaction-mode-map
  (let ((map (make-sparse-keymap)))
    (define-key map (kbd "C-x C-e") #'jupyter-eval-line-or-region)
    (define-key map (kbd "C-c C-c") #'jupyter-eval-line-or-region)
    (define-key map (kbd "C-M-x") #'jupyter-eval-defun)
    (define-key map (kbd "C-c C-o") #'jupyter-eval-remove-overlays)
    (define-key map (kbd "C-c C-s") #'jupyter-repl-scratch-buffer)
    (define-key map (kbd "C-c C-b") #'jupyter-eval-buffer)
    (define-key map (kbd "C-c C-l") #'jupyter-load-file)
    (define-key map (kbd "C-c M-:") #'jupyter-eval-string-command)
    (define-key map (kbd "M-i") #'jupyter-inspect-at-point)
    (define-key map (kbd "C-c C-r") #'jupyter-repl-restart-kernel)
    (define-key map (kbd "C-c C-i") #'jupyter-repl-interrupt-kernel)
    (define-key map (kbd "C-c C-z") #'jupyter-repl-pop-to-buffer)
    map))

(define-minor-mode jupyter-repl-interaction-mode
  "Minor mode for interacting with a Jupyter REPL.
When this minor mode is enabled you may evaluate code from the
current buffer using the associated REPL (see
`jupyter-repl-associate-buffer' to associate a REPL).

In addition any new buffers opened with the same `major-mode' as
the `current-buffer' will automatically have
`jupyter-repl-interaction-mode' enabled for them.

\\{jupyter-repl-interaction-mode-map}"
  :group 'jupyter-repl
  :lighter (:eval (jupyter-repl-interaction-mode-line))
  :init-value nil
  (cond
   (jupyter-repl-interaction-mode
    (add-hook 'completion-at-point-functions
              'jupyter-completion-at-point
              jupyter-repl-completion-at-point-hook-depth
              t)
    (add-hook 'after-revert-hook 'jupyter-repl-interaction-mode nil t))
   (t
    (remove-hook 'completion-at-point-functions 'jupyter-completion-at-point t)
    (remove-hook 'after-revert-hook 'jupyter-repl-interaction-mode t)
    (unless (eq major-mode 'jupyter-repl-mode)
      (kill-local-variable 'jupyter-current-client)))))

(defun jupyter-repl-interaction-mode-reenable ()
  "Re-enable `jupyter-repl-interaction-mode' in the current buffer.
Do so only if possible in the `current-buffer'."
  (when (and (not jupyter-repl-interaction-mode)
             (cl-typep jupyter-current-client 'jupyter-repl-client)
             (eq major-mode
                 (jupyter-kernel-language-mode jupyter-current-client)))
    (jupyter-repl-interaction-mode)))

(defun jupyter-repl-interaction-mode-line ()
  "Return a mode line string with the status of the kernel.
`*' means the kernel is busy, `-' means the kernel is idle and
the REPL is connected, `x' means the REPL is disconnected
from the kernel."
  (pcase jupyter-current-client
    ((and client (cl-type jupyter-repl-client))
     (format jupyter-repl-interaction-mode-line-format
             (cond
              ((not (jupyter-hb-beating-p client)) "x")
              ((equal (jupyter-execution-state client) "busy") "*")
              (t "-"))))))

(defun jupyter-repl-pop-to-buffer ()
  "Switch to the REPL buffer of the `jupyter-current-client'."
  (interactive)
  (if jupyter-current-client
      (jupyter-with-repl-buffer jupyter-current-client
        (goto-char (point-max))
        (pop-to-buffer (current-buffer)))
    (error "Buffer not associated with a REPL, see `jupyter-repl-associate-buffer'")))

(defun jupyter-repl-available-repl-buffers (&optional mode first)
  "Return a list of REPL buffers that are connected to live kernels.
If MODE is non-nil, return all REPL buffers whose
`jupyter-repl-lang-mode' is MODE.

If FIRST is non-nil, only return the first REPL buffer that matches."
  (cl-loop
   for client in (jupyter-all-objects 'jupyter--clients)
   for match =
   (when (and (object-of-class-p client 'jupyter-repl-client)
              (buffer-live-p (oref client buffer)))
     (with-current-buffer (oref client buffer)
       (and (or (null mode)
                (provided-mode-derived-p mode jupyter-repl-lang-mode))
            (jupyter-repl-connected-p)
            (buffer-name))))
   if (and match first) return (oref client buffer)
   else if match collect (oref client buffer)))

;;;###autoload
(defun jupyter-repl-associate-buffer (client)
  "Associate the `current-buffer' with a REPL CLIENT.
If the `major-mode' of the `current-buffer' is the
`jupyter-repl-lang-mode' of CLIENT, call the function
`jupyter-repl-interaction-mode' to enable the corresponding mode.

CLIENT should be the symbol `jupyter-repl-client' or the symbol
of a subclass.  If CLIENT is a buffer or the name of a buffer, use
the `jupyter-current-client' local to the buffer."
  (interactive
   (list
    (when-let* ((buffer (jupyter-repl-completing-read-repl-buffer major-mode)))
      (buffer-local-value 'jupyter-current-client buffer))))
  (if (not client)
      (when (y-or-n-p "No REPL for `major-mode' exists.  Start one? ")
        (call-interactively #'jupyter-run-repl))
    (setq client (if (or (bufferp client) (stringp client))
                     (with-current-buffer client
                       jupyter-current-client)
                   client))
    (unless (object-of-class-p client 'jupyter-repl-client)
      (error "Not a REPL client (%s)" client))
    (unless (eq (jupyter-kernel-language-mode client) major-mode)
      (error "Cannot associate buffer to REPL.  Wrong `major-mode'"))
    (setq-local jupyter-current-client client)
    (unless jupyter-repl-interaction-mode
      (jupyter-repl-interaction-mode))))

(defun jupyter-repl-propagate-client (buffer &rest _)
  "Set the `jupyter-current-client' in BUFFER.
If BUFFER's value of the variable `jupyter-repl-interaction-mode'
is nil and the buffer has the same `major-mode' as the
`jupyter-current-client's language mode, set the buffer local
value of `jupyter-current-client' in BUFFER to the current value
of that variable."
  (when (and jupyter-current-client
             (cl-typep jupyter-current-client 'jupyter-repl-client)
             (or (bufferp buffer) (stringp buffer))
             (setq buffer (get-buffer buffer))
             (buffer-live-p buffer)
             (null (buffer-local-value 'jupyter-repl-interaction-mode buffer))
             (eq (buffer-local-value 'major-mode buffer)
                 (jupyter-kernel-language-mode jupyter-current-client)))
    (let ((client jupyter-current-client))
      (with-current-buffer buffer
        (jupyter-repl-associate-buffer client)))))

(defun jupyter-repl--before-switch-to-buffer (buffer &rest _)
  "Call `jupyter-repl-propagate-client' on BUFFER, handling a nil BUFFER.
When BUFFER is nil use `other-buffer'."
  (jupyter-repl-propagate-client (or buffer (other-buffer))))

(defun jupyter-repl--before-set-window-buffer (_ buffer &rest __)
  "Call `jupyter-repl-propagate-client' on BUFFER."
  (jupyter-repl-propagate-client buffer))

;;; `jupyter-repl-persistent-mode'

;;;###autoload
(define-minor-mode jupyter-repl-persistent-mode
  "Global minor mode to persist Jupyter REPL connections.
When the `jupyter-current-client' of the current buffer is a REPL
client, its value is propagated to all buffers switched to that
have the same `major-mode' as the client's kernel language and
`jupyter-repl-interaction-mode' is enabled in those buffers."
  :group 'jupyter-repl
  :global t
  :keymap nil
  :init-value nil
  (cond
   (jupyter-repl-persistent-mode
    (advice-add 'switch-to-buffer :before #'jupyter-repl--before-switch-to-buffer)
    (advice-add 'display-buffer :before #'jupyter-repl-propagate-client)
    (advice-add 'set-window-buffer :before #'jupyter-repl--before-set-window-buffer)
    (add-hook 'after-change-major-mode-hook 'jupyter-repl-interaction-mode-reenable))
   (t
    (advice-remove 'switch-to-buffer #'jupyter-repl--before-switch-to-buffer)
    (advice-remove 'display-buffer #'jupyter-repl-propagate-client)
    (advice-remove 'set-window-buffer #'jupyter-repl--before-set-window-buffer)
    (remove-hook 'after-change-major-mode-hook 'jupyter-repl-interaction-mode-reenable))))

;;; Starting a REPL

(cl-defgeneric jupyter-bootstrap-repl (client &optional repl-name associate-buffer display)
  "Initialize a new REPL buffer based on CLIENT, return CLIENT.
CLIENT should be a REPL client already connected to its kernel.

A new REPL buffer communicating with CLIENT's kernel is created
and set as CLIENT's buffer slot.  If CLIENT already has a non-nil
buffer slot, do nothing.

REPL-NAME is a string that will be used to generate the buffer
name.  If nil or empty, a default will be used.

If ASSOCIATE-BUFFER is non-nil, attempt to \"connect\" the
`current-buffer' to the REPL (see
`jupyter-repl-associate-buffer') if it is compatible with the
underlying kernel.

If DISPLAY is non-nil, display the REPL buffer after
completing all of the above.")

(cl-defmethod jupyter-bootstrap-repl :before ((_client jupyter-repl-client)
                                              &optional _repl-name _associate-buffer _display)
  "Enable `jupyter-repl-persistent-mode' if needed."
  (unless jupyter-repl-persistent-mode (jupyter-repl-persistent-mode)))

(cl-defmethod jupyter-bootstrap-repl :after ((client jupyter-repl-client)
                                             &optional _repl-name associate-buffer display)
  (when (and associate-buffer
             (eq major-mode (jupyter-kernel-language-mode client)))
    (jupyter-repl-associate-buffer client))
  (when display
    (pop-to-buffer (oref client buffer))))

(cl-defmethod jupyter-bootstrap-repl ((client jupyter-repl-client)
                                      &optional repl-name _associate-buffer _display)
  (prog1 client
    (unless (oref client buffer)
      (cl-destructuring-bind (&key language_info
                                   banner
                                   &allow-other-keys)
          (jupyter-kernel-info client)
        (let ((language-name (plist-get language_info :name))
              (language-version (plist-get language_info :version)))
          (oset client buffer
                (generate-new-buffer
                 (format "*jupyter-repl[%s]*"
                         (if (zerop (length repl-name))
                             (format "%s %s" language-name language-version)
                           repl-name))))
          (jupyter-with-repl-buffer client
            (setq-local jupyter-current-client client)
            (jupyter-repl-mode)
            (jupyter-repl-insert-banner banner)
            (jupyter-repl-insert-prompt 'in)))))))

(defvar jupyter--repl-server nil)

(defun jupyter-repl-server ()
  "Return a `jupyter-server' that can be used to launch REPLs with.
The server is used by the `jupyter-run-repl' command to launch
all of its REPLs if `jupyter-use-zmq' is nil."
  (let ((server jupyter--repl-server))
    (if (and server
             (process-live-p
              (jupyter-notebook-process server)))
        server
      (let* ((port (jupyter-launch-notebook))
             (url (format "http://localhost:%s" port)))
        (setq jupyter--repl-server
              (jupyter-server :url url))))))

;;;###autoload
(defun jupyter-run-repl (kernel-name &optional repl-name associate-buffer client-class display)
  "Run a Jupyter REPL connected to a kernel with name, KERNEL-NAME.
KERNEL-NAME will be passed to `jupyter-guess-kernelspec' and the
first kernel found will be used to start the new kernel.

With a prefix argument give a new REPL-NAME for the REPL.

Optional argument ASSOCIATE-BUFFER, if non-nil, means to enable
the REPL interaction mode by calling the function
`jupyter-repl-interaction-mode' in the `current-buffer' and
associate it with the REPL created.  When called interactively,
ASSOCIATE-BUFFER is set to t.  If the `current-buffer's
`major-mode' does not correspond to the language of the kernel
started, ASSOCIATE-BUFFER has no effect.

Optional argument CLIENT-CLASS is a subclass of
`jupyter-repl-client' that the REPL client will be an instance
of.  The default is `jupyter-repl-client'.

When called interactively, DISPLAY the new REPL buffer.
Otherwise, in a non-interactive call, return the REPL client
connected to the kernel.

Note, if `default-directory' is a remote directory, a kernel will
start on the remote host by using the \"jupyter kernel\" shell
command on the host."
  (interactive (list (jupyter-kernelspec-name
                      (jupyter-completing-read-kernelspec
                       nil current-prefix-arg))
                     (when current-prefix-arg
                       (read-string "REPL Name: "))
                     t nil t))
  (or client-class (setq client-class 'jupyter-repl-client))
  (jupyter-error-if-not-client-class-p client-class 'jupyter-repl-client)
  (jupyter-bootstrap-repl
   (let ((spec (jupyter-kernelspec-name
                (jupyter-guess-kernelspec kernel-name))))
     (jupyter-client
      (if jupyter-use-zmq
          (jupyter-kernel :spec spec)
        (jupyter-kernel
         :server (jupyter-repl-server)
         :spec spec))
      client-class))
   repl-name associate-buffer display))

;;;###autoload
(defun jupyter-connect-repl (file &optional repl-name associate-buffer client-class display)
  "Run a Jupyter REPL using a kernel's connection FILE.
Return the REPL client connected to the kernel.  When called
interactively, DISPLAY the new REPL buffer as well.  With a
prefix argument give a new REPL-NAME for the REPL.

FILE is the name of a connection file that will be read.
ASSOCIATE-BUFFER has the same meaning as in `jupyter-run-repl'.
Optional argument CLIENT-CLASS is the class of the client that
will be used to initialize the REPL and should be a class symbol
like the symbol `jupyter-repl-client', which is the default. "
  (interactive (list (read-file-name "Connection file: ")
                     (when current-prefix-arg
                       (read-string "REPL Name: "))
                     t nil t))
  (or client-class (setq client-class 'jupyter-repl-client))
  (jupyter-error-if-not-client-class-p client-class 'jupyter-repl-client)
  (jupyter-bootstrap-repl
   (jupyter-client
    (jupyter-kernel
     :conn-info file
     :connect-p t
     ;; Interrupting a kernel with a message is the only way to
     ;; interrupt kernels connected to using a connection file since
     ;; there is no way of telling what kind of kernel it is that is
     ;; being connected to using this method.  See
     ;; `jupyter-interrupt-kernel'.
     :spec (make-jupyter-kernelspec
            :plist '(:interrupt_mode "message")))
    client-class)
   repl-name associate-buffer display))

(provide 'jupyter-repl)

;;; jupyter-repl.el ends here
